iT邦幫忙

2024 iThome 鐵人賽

DAY 22
0
JavaScript

TypeScript 初學者也能看的學習指南系列 第 22

TypeScript 初學者也能看的學習指南 22 - Conditional Types 條件型別

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20241002/201493622KSrDIEJiJ.png

說到 條件型別 會聯想到 infer extends 三元運算子 這三個關鍵字
本文除了介紹「條件型別」的概念外,也會提到條件型別的約束(constraints)、推斷(infer)、可分配性(distributive)

Conditional Types 條件型別

在 TypeScript 中 extends 關鍵字除了用在 interface 和 Class 的繼承外,還可以用在「條件型別」
「條件型別」中的 extends 是允許我們可以根據某個條件來選擇兩種型別中的其中一個

條件型別的基本語法

A extends B ? C : D

  • 檢查 A 型別是否可以賦值給 B 型別。這裡的「是否可以賦值給」等同於「A 是否是 B 的子型別」、「A 是否兼容 B」
  • 如果 A 型別可以賦值給 B 型別,那麼結果就是 C 型別,否則則是 D 型別

範例

  • 基本範例
interface Animal {
  live(): void;
}
interface Dog extends Animal {
  woof(): void;
}
 
type Example1 = Dog extends Animal ? number : string; // ✅ type Example1 = number
       
 
type Example2 = RegExp extends Animal ? number : string; // ✅ type Example2 = string

以範例中的 type Example1 來說
Dog 是 Animal 的子型別,所以 type Example1 的結果是 number

至於 Example2
RegExp 不是 Animal 的子型別,所以 type Example2 的結果是 string


  • 進階範例:條件型別 + 泛型
interface IdLabel {
  id: number;
}
interface NameLabel {
  name: string;
}
 
function createLabel(id: number): IdLabel;
function createLabel(name: string): NameLabel;
function createLabel(nameOrId: string | number): IdLabel | NameLabel;
function createLabel(nameOrId: string | number): IdLabel | NameLabel {
  throw "unimplemented";
}

createLabel 函式中我們不難看出

  1. 當傳入 number 時,會回傳 IdLabel
  2. 當傳入 string 時,會回傳 NameLabel
  3. 當傳入 string | number時,可以回傳 IdLabel | NameLabel

但這也造成一些問題,增加後續維護的負擔

  1. 重複的選擇邏輯
  2. overloads 的增長

條件型別的真正威力在於能與 generics 泛型 結合使用,這使函式可以根據輸入的型別來動態決定回傳的型別

上述 code 簡化後如下(想複習觀念的推薦也可以試著先自己來簡化看看喔!)

interface IdLabel {
  id: number;
}
interface NameLabel {
  name: string;
}

// 條件型別的定義
type NameOrId<T extends number | string> = T extends number ? IdLabel : NameLabel;

// 條件型別的實現
function createLabel<T extends number | string>(idOrName: T): NameOrId<T> {
  throw "unimplemented";
}

let a = createLabel("typescript"); // ✅ let a: NameLabel
let b = createLabel(2.8);  // ✅ let b: IdLabel
let c = createLabel(Math.random() ? "hello" : 42); // ✅  let c: NameLabel | IdLabel

這裡使用條件型別結合「generics 泛型」的方式定義一個型別別名,並把多個重載 (overloads) 的函式簡化為沒有重載的單一函式

來拆解一下上面這段簡化後的 code

  • NameOrId<T> 為條件型別
  • <T extends number | string>:泛型 T 必須是 number 或 string 的子類型。確保只有數字和字符串可以作為這個條件型別的輸入
  • T extends number ? IdLabel : NameLabel:如果 T 是 number 的子型別,則回傳 IdLabel 型別,否則則回傳 NameLabel 型別

條件型別的約束(constraints)

type MessageOf<T> = T["message"];  // ❌ Type '"message"' cannot be used to index type 'T'.

在上面這個例子中,TypeScript 出錯是因為它不知道泛型 T 是否擁有 message 的屬性。我們可以透過約束(constraints) T 的結構,讓錯誤消失

PS. T["message"]「索引訪問」

type MessageOf<T extends { message: unknown }> = T["message"];

MessageOf<T extends { message: unknown }> T 繼承了 { message: unknown },這表示 T 必須是包含 message 屬性的「物件」。只要擁有 message 屬性的物件才能作為 MessageOf<T> 的泛型參數
如此一來我們就能確認 message 屬性是否存在惹~

接著來使用看看

type MessageOf<T extends { message: unknown }> = T["message"];

interface Email {
  message: string;
}
 
type EmailMessageContents = MessageOf<Email>;

最後一行的 MessageOf<Email> 會取得 Email 介面中 message 的型別。因此 EmailMessageContents 的型別也會是 string

最後是來加入條件型別

type MessageOf<T> = T extends { message: unknown } ? T["message"] : never;

interface Email {
  message: string;
}

interface Dog {
  bark(): void;
}
type EmailMsgContents = MessageOf<Email>; // ✅ type EmailMsgContents = string
type DogMsgContents = MessageOf<Dog>;     // ✅ type DogMessageContents = never

條件型別的推斷(infer)

https://ithelp.ithome.com.tw/upload/images/20241003/20149362G1YKSa3HuZ.png

假設你正在玩一個猜物品的遊戲,其中每個盒子裡都裝了不同的物品,在不打開盒子的情況下,你只能根據盒子外面的提示來猜測盒子裡面的物品可能是什麼
在 TypeScript 裡,infer 是個偷吃步(?,讓你能夠打開盒子並直接看到裡面的物品是什麼

我們不需要事先知道所有的具體型別,這就像是在不知道盒子裡具體有什麼的情況下,依然能夠查看裡面的物品

條件型別的推斷會使用 infer 關鍵字

type Flatten<Type> = Type extends Array<infer Item> ? Item : Type;

type NumbersArray = Flatten<number[]>;  // ✅ type NumbersArray = number
type StringVal = Flatten<string>;       // ✅ type StringVal =  string
  • Type extends Array<infer Item> ? Item : Type:表示如果 Type 是一個 array,那就推斷出這個 array 的元素型別,並將其儲存於 Item,這個 Item 可以隨意替換不同名稱

條件型別的可分配性(distributive)

https://ithelp.ithome.com.tw/upload/images/20241003/20149362yoitNCshtX.png
「條件型別的可分配性」是指條件型別可以逐一應用於聯合型別中的每個成員,然後將結果再次組合成一個新的聯合型別

假設今天有一個盒子,裏面放了 🔴 紅色、🔵 藍色、🟡 黃色 的球
「紅色球」代表「Yes」,「非紅色球」代表「No」

Q:盒子裡的球是否是紅色

按照分配性,會這樣回答

A:
紅色球? "Yes"
藍色球? "No"
黃色球? "No"
不論如何,你的答案只會是 "Yes" 或 "No"

在 TypeScript 中,當對聯合型別(比如 string | number | boolean)使用條件型別時,TypeScript 會對每個可能的型別 (string, number, boolean)分別應用這個條件,並給出每個情況的結果,最後把這些結果組合起來,形成一個新的聯合型別 ('Yes' | 'No')

以上這是說明可分配性的「概念」
轉換成 code 如下:

type IsString<T> = T extends string ? 'Yes' : 'No';

// 聯合型別
type MixedTypes = string | number | boolean;

// 聯合型別 結合「條件型別」
type Result = IsString<MixedTypes>;  // Result 的型別會是 'Yes' | 'No'

如果不希望條件型別具有分配性,可以將型別封裝在「元組」中。這樣 TypeScript 就會將整個聯合型別作為一個單一實體來處理,而不是分別處理每個成員

type NonDistributive<T> = [T] extends [string] ? 'Yes' : 'No';

每天的內容有推到 github 上喔

Rederences


上一篇
TypeScript 初學者也能看的學習指南 21 - Type Alias 型別別名
下一篇
TypeScript 初學者也能看的學習指南 23 - Generics 泛型 X 泛型函式
系列文
TypeScript 初學者也能看的學習指南30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言